上一篇我們實作城市的定位,但用戶仍必須手動轉動地球。手動轉動地球體驗不是很好,而這對簡報、展示來說是一大傷害。事實上,地球並不僅只是給客戶用而已。它除了當作畫面第一個登場的物件以外,公司業務拿畫面出來賣產品時,你所設計的畫面體驗也留給了客戶第一印象。客戶可能在第一時間就評分了整個產品。
所以說,地球不僅只是好用好看而已,對於商業價值有相當的影響。
回顧原因,它可以應用在很多場景上,例如:行銷網站、企業形象網站、活動網站、全球數位戰情室、航太科技、GIS畫面等等。這些對於前端視覺特效都非常重要。
製作地球也能讓我們釐清貼圖底層的運作模式,不僅討論到底層webGL、fragmentShader、vertexShader的渲染方式,也提到很多種貼圖。
Vector3.lerp()
轉動鏡頭位置normalize()
、mutiplyScalar()
鎖定鏡頭與地球的高度Math.cos()
、Math.pow()
控制鏡頭位移的軌道TextGeometry()
建立浮動的3D文字物件這裡也有codepen:
https://codepen.io/umas-sunavan/pen/NWMXYwZ
由於已經留下不少技術債。為了增加閱讀效率,我把部分的程式碼用函式包住:
const skydome = sreateSkydome()
const earth = createEarth()
const cloud = createCloud()
const ring = createRing()
這四個函式將一些變數留在函式作用域,並且減少全域的複雜度。
以下是整理過的程式碼,我們從這裡繼續。當然,如果沒有整理仍然可以繼續向下開發。
直接複製貼上就可以使用了。
import * as THREE from 'three';
import { OrbitControls } from 'https://unpkg.com/three@latest/examples/jsm/controls/OrbitControls.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 10, 15)
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const sreateSkydome = () => {
// 匯入材質
// image source: https://www.deviantart.com/kirriaa/art/Free-star-sky-HDRI-spherical-map-719281328
const skydomeTexture = new THREE.TextureLoader().load('https://storage.googleapis.com/umas_public_assets/michaelBay/free_star_sky_hdri_spherical_map_by_kirriaa_dbw8p0w%20(1).jpg')
// 帶入材質,設定內外面
const skydomeMaterial = new THREE.MeshBasicMaterial({ map: skydomeTexture, side: THREE.DoubleSide })
const skydomeGeometry = new THREE.SphereGeometry(100, 50, 50)
const skydome = new THREE.Mesh(skydomeGeometry, skydomeMaterial);
scene.add(skydome);
return skydome
}
// 新增環境光
const addAmbientLight = () => {
const light = new THREE.AmbientLight(0xffffff, 0.5)
scene.add(light)
}
// 新增點光
const addPointLight = () => {
const pointLight = new THREE.PointLight(0xffffff, 1)
scene.add(pointLight);
pointLight.position.set(10, 10, -10)
pointLight.castShadow = true
// 新增Helper
const lightHelper = new THREE.PointLightHelper(pointLight, 5, 0xffff00)
// scene.add(lightHelper);
// 更新Helper
lightHelper.update();
}
// 新增平行光
const addDirectionalLight = () => {
const directionalLight = new THREE.DirectionalLight(0xffffff, 1)
directionalLight.position.set(0, 0, 10)
scene.add(directionalLight);
directionalLight.castShadow = true
const d = 10;
directionalLight.shadow.camera.left = - d;
directionalLight.shadow.camera.right = d;
directionalLight.shadow.camera.top = d;
directionalLight.shadow.camera.bottom = - d;
// 新增Helper
const lightHelper = new THREE.DirectionalLightHelper(directionalLight, 5, 0xffff00)
// scene.add(lightHelper);
// 更新位置
directionalLight.target.position.set(0, 0, 0);
directionalLight.target.updateMatrixWorld();
// 更新Helper
lightHelper.update();
}
addPointLight()
addAmbientLight()
addDirectionalLight()
const createEarth = () => {
const earthGeometry = new THREE.SphereGeometry(5, 600, 600)
const earthTexture = new THREE.TextureLoader().load('https://storage.googleapis.com/umas_public_assets/michaelBay/day10/8081_earthmap2k.jpg')
// 灰階高度貼圖
const displacementTexture = new THREE.TextureLoader().load('https://storage.googleapis.com/umas_public_assets/michaelBay/day10/editedBump.jpg')
const roughtnessTexture = new THREE.TextureLoader().load('https://storage.googleapis.com/umas_public_assets/michaelBay/day10/8081_earthspec2kReversedLighten.png')
const speculatMapTexture = new THREE.TextureLoader().load('https://storage.googleapis.com/umas_public_assets/michaelBay/day10/8081_earthspec2k.jpg')
const bumpTexture = new THREE.TextureLoader().load('https://storage.googleapis.com/umas_public_assets/michaelBay/day10/8081_earthbump2k.jpg')
const earthMaterial = new THREE.MeshStandardMaterial({
map: earthTexture,
side: THREE.DoubleSide,
roughnessMap: roughtnessTexture,
roughness: 0.9,
// 將貼圖貼到材質參數中
metalnessMap: speculatMapTexture,
metalness: 1,
displacementMap: displacementTexture,
displacementScale: 0.5,
bumpMap: bumpTexture,
bumpScale: 0.1,
})
const earth = new THREE.Mesh(earthGeometry, earthMaterial);
scene.add(earth);
return earth
}
const createCloud = () => {
const cloudGeometry = new THREE.SphereGeometry(5.4, 60, 60)
// 匯入材質
// texture source: http://planetpixelemporium.com/earth8081.html
const cloudTransparency = new THREE.TextureLoader().load('8081_earthhiresclouds4K.jpg')
// 帶入材質,設定內外面
const cloudMaterial = new THREE.MeshStandardMaterial({
transparent: true,
opacity: 1,
alphaMap: cloudTransparency
})
const cloud = new THREE.Mesh(cloudGeometry, cloudMaterial);
scene.add(cloud);
return cloud
}
const createRing = () => {
const geo = new THREE.RingGeometry( 0.1, 0.13, 32 );
const mat = new THREE.MeshBasicMaterial( { color: 0xffff00, side: THREE.DoubleSide } );
const ring = new THREE.Mesh( geo, mat );
scene.add( ring );
return ring
}
const control = new OrbitControls(camera, renderer.domElement);
const cities = [
{ name: "--- select city ---", id: 0, lat: 0, lon: 0, country: "None" },
{ name: "Mumbai", id: 1356226629, lat: 19.0758, lon: 72.8775, country: "India" },
{ name: "Moscow", id: 1643318494, lat: 55.7558, lon: 37.6178, country: "Russia" },
{ name: "Xiamen", id: 1156212809, lat: 24.4797, lon: 118.0819, country: "China" },
{ name: "Phnom Penh", id: 1116260534, lat: 11.5696, lon: 104.9210, country: "Cambodia" },
{ name: "Chicago", id: 1840000494, lat: 41.8373, lon: -87.6862, country: "United States" },
{ name: "Bridgeport", id: 1840004836, lat: 41.1918, lon: -73.1953, country: "United States" },
{ name: "Mexico City", id: 1484247881, lat:19.4333, lon: -99.1333 , country: "Mexico" },
{ name: "Karachi", id: 1586129469, lat:24.8600, lon: 67.0100 , country: "Pakistan" },
{ name: "London", id: 1826645935, lat:51.5072, lon: -0.1275 , country: "United Kingdom" },
{ name: "Boston", id: 1840000455, lat:42.3188, lon: -71.0846 , country: "United States" },
{ name: "Taichung", id: 1158689622, lat:24.1500, lon: 120.6667 , country: "Taiwan" },
]
let lerpTarget
let lerpPropical = new THREE.Vector3(0,0,0)
let tropical
const citySelect = document.getElementsByClassName('citySelect')[0]
citySelect.innerHTML = cities.map( city => `<option value="${city.id}">${city.name}</option>`)
citySelect.addEventListener( 'change', (event) => {
const cityId = event.target.value
const seletedCity = cities.find(city => city.id+'' === cityId)
const cityEciPosition = lonLauToRadian(seletedCity.lon, seletedCity.lat, 4.4)
ring.position.set(cityEciPosition.x, -cityEciPosition.z, -cityEciPosition.y)
const center = new THREE.Vector3(0,0,0)
ring.lookAt(center)
tropical = 1
lerpTarget = new THREE.Vector3(0,0,0).set(...ring.position.toArray()).multiplyScalar(3)
lerpPropical.set(...camera.position.toArray())
// camera.position.set(...ring.position.toArray()).multiplyScalar(3)
control.update()
})
const skydome = sreateSkydome()
const earth = createEarth()
const cloud = createCloud()
const ring = createRing()
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
cloud.rotation.y += 0.0005
skydome.rotation.y += 0.001
if (lerpTarget) {
lerpPropical.lerp(lerpTarget, 0.05).normalize().multiplyScalar(20)
let value = Math.pow(tropical*2-1, 4.)
camera.position.set(lerpPropical.x, lerpPropical.y*(value), lerpPropical.z).normalize().multiplyScalar(20)
control.update()
}
tropical*=0.97
}
animate();
// 經緯度轉換成弧度
const lonLauToRadian = (lon, lat, rad) => llaToEcef(Math.PI * (0 - lat) / 180, Math.PI * (lon / 180), 1, rad)
// 城市弧度轉換成世界座標
const llaToEcef = (lat, lon, alt, rad) => {
let f = 0
let ls = Math.atan((1 - f) ** 2 * Math.tan(lat))
let x = rad * Math.cos(ls) * Math.cos(lon) + alt * Math.cos(lat) * Math.cos(lon)
let y = rad * Math.cos(ls) * Math.sin(lon) + alt * Math.cos(lat) * Math.sin(lon)
let z = rad * Math.sin(ls) + alt * Math.sin(lat)
return new THREE.Vector3(x, y, z)
}
設定鏡頭定位在城市位置正上方的外太空即可。
如何取得城市位置正上方的外太空位置?將城市position
向量放大三倍即可。(透過multiplyScalar()
,就在之前的必備的向量函式時都有提及)
// 用戶更新下拉選單後的回呼
citySelect.addEventListener( 'change', (event) => {
...
// 修改鏡頭位置,multiplyScalar可以縮放向量
camera.position.set(...ring.position.toArray()).multiplyScalar(3)
// 由OrbitControl幫我們更新鏡頭角度
control.update()
})
可以看到用戶已經可以切換位置了
在討論必備的向量函式時,有提到lerp。現在它派上用場了。如果一個向量使用它,得提供兩個參數:結果參數跟alpha參數,向量將移動alpha倍的距離向結果參數(另一個向量)移動。
所以比方說,我要讓向量(0,0,0)
向結果參數(10,10,0)
移動0.5倍距離(alpha參數),則結果就是(5,5,0)
。再執行一次就變成(7.5, 7.5, 0)
,再一次就是(0.875, 0.875, 0)
,以此類推。
把這個函式放在animate裡面,就可以做出像是Ease-out的動畫效果,連動畫套件都不用裝,讚。
// 作為lerp移動的結果參數
let lerpTarget
citySelect.addEventListener( 'change', (event) => {
...
// 當用戶選城市時,更新lerp移動的結果參數
lerpTarget = new THREE.Vector3(0,0,0)
// 設定移動結果位置為圖釘位置
.set(...ring.position.toArray())
// 乘以三倍,使得位置位在城市正上方的外太空
.multiplyScalar(3)
// 不在此直接設定鏡頭位置了
camera.position.set(...ring.position.toArray()).multiplyScalar(3)
control.update()
})
接著,我們在animate()加上函式,使得鏡頭不斷的位移,實現動畫:
function animate() {
...
// 用戶有選取城市才會執行下面
if (lerpTarget) {
// 鏡頭位置向城市上方的外太空移動
camera.position.lerp(lerpTarget, 0.05)
// 使得OrbitControl不斷幫我們更新鏡頭
control.update()
}
}
接著就能做出效果:
畫面看起來非常好,但事情還沒有結束。
你玩一玩會發現,怎麼怪怪的?
你會發現,鏡頭移動的路徑是直線的。
我們需要把路徑改成曲線。
為什麼會產生這個問題?
鏡頭在位移時,其位置距離地球的遠近不一樣。當鏡頭在起點跟終點時,鏡頭距離地球的距離一致,然而在位移的過程中,其距離地球過近。
而解決方法,就是在鏡頭移動的過程中,持續固定鏡頭對地球的距離。固定距離的方法有很多種,而我的作法有三個步驟:
我們看圖理解:
步驟一:一開始離世界中心距離假設為20。(世界中心也是地球中心)
步驟二:現在將該向量縮小到長度為1,但方向不變。這個可以透過Vector3.normalize()
完成
步驟三:一開始我們知道長度為20,所以只要再乘上20即可。
如果每一幀鏡頭移動時都做同樣的事情,那麼就可以形成一個完美的弧度
Vector3.normalize()
可以把向量轉成單位向量,以此固定距離成一單位;Vector3.multiplyScalar()
則能夠縮放向量到正確的距離。有這兩個函式,就可以開始把邏輯寫在程式了。
這兩個都在我們介紹必備的向量函式時都有提及,可以參考:Day7: three.js的一方通行:矢量操作——全面釐清向量與底層特性
套用在我們專案,就是:
- camera.position.lerp(lerpTarget, 0.1)
+ // 固定長度為一單位,然後放大長度
+ camera.position.lerp(lerpTarget, 0.1).normalize().multiplyScalar(15)
結果就自然多了:
OrbitControl
自動轉正的特性以及解法當鏡頭靠近北極時,會有奇怪的旋轉。
為什麼會有奇怪的旋轉?
這跟OrbitControl
的特性有關。OrbitControl
一個很大的特性在Day6: three.js 圓弧的藝術家!弧線的教授!——軌道控制器有提到:當用戶把鏡頭繞過最頂端之後,OrbitControls
會自動校正頭頂方向。
也是因為這個特性,讓鏡頭在繞過北極的時候,有不自然的旋轉。
為了解決這個方法,我加上了一個函式,來讓鏡頭沿著赤道旋轉,避免這個問題。
因為鏡頭往北極時都要轉正,為了避免這個問題,我改從赤道旋轉鏡頭。但要如何開發呢?
目前為止,我們有lerpTarget
,它每一幀都移動一個距離,並讓鏡頭綁定給它lerpTarget
。
現在,我們多出一個變數,先頂替camera的路徑移動,我們稱這個變數作moveAlongTropical
好了。我們接著修改moveAlongTropical
的數值,使得它沿著赤道移動,再綁定移動軌跡給鏡頭。
要如何修改moveAlongTropical
的數值來讓鏡頭沿著赤道移動?從下圖可見紅字,假設紅字代表是一個變數,它將lerpTargt
的Y軸數值乘成比較小的數值,就可以改變鏡頭的位置了。
如圖中紅字那樣,只要lerpTarget
座標中的高度Y乘上一個變數,即可將鏡頭偏向赤道移動。我將該變數命名為moveVolume
,待會解釋。
在程式碼實作是這樣:
let lerpTarget
// 加上兩個變數
let moveAlongTropical = new THREE.Vector3(0,0,0)
// moveAlongTropical的移動進度
let moveProgress
在此我們宣告兩個變數。當用戶點選新的城市,設moveProgress
為1,將由1走到0,做為moveAlongTropical
移動的進度。
citySelect.addEventListener( 'change', (event) => {
...
// 給定moveAlongTropical於移動的起點
moveAlongTropical.set(...camera.position.toArray())
// pregress將由1走到0,控制稍候的變數「moveVolume」以做變化
moveProgress = 1
})
每一幀,moveAlongTropical
都會頂替鏡頭原先的位置。moveVolume
使得鏡頭的Y座標保持在赤道。
moveVolume
為什麼能使鏡頭在赤道?因為moveVolume
範圍是1~0,它乘給了Y,導致Y值減少了,也因此使得鏡頭靠近赤道。
function animate() {
...
// 建立一個函式,使得鏡頭的航向可以往赤道移動
let moveVolume = Math.pow(moveProgress*2-1, 4.)
// 用戶有選取城市才會執行下面
if (lerpTarget) {
// 綁定數值給moveAlongTropical
moveAlongTropical.lerp(lerpTarget, 0.05).normalize().multiplyScalar(15)
// 現在,將camera位置綁定到moveAlongTropical上。其中由於moveVolume範圍是1~0,其減少了Y值的輸出
camera.position.set(moveAlongTropical.x, moveAlongTropical.y*moveVolume, moveAlongTropical.z).normalize().multiplyScalar(20)
// 使得OrbitControl不斷幫我們更新鏡頭
control.update()
}
// 不斷更新progress,使得moveVolume不斷更新數值
moveProgress*=0.97
}
如此一來,地球就可以沿著赤道移動,解決鏡頭繞過北極時的問題了。
有兩種開發方式,一種是將文字當作一個Mesh,一種是將文字當作是一個html DOM,這兩種都可以。前者提供多元的文字渲染,後者提供用戶複製文字並作後續操作。我介紹前者為主。
首先加上一個函式以新增文字Mesh。身為一個Mesh,它就跟前幾篇介紹的物件一樣,需要形狀(geometry
)跟材質(material
),文字可用TextGeometry
。只是textGeometry
的參數較多。儘管如此,這些參數仍然很好理解。
const addText = text => {
const textGeometry = new TextGeometry( text, {
font: font,
size: 0.2,
height: 0.01,
curveSegments: 2,
bevelEnabled: false,
bevelThickness: 10,
bevelSize: 0,
bevelOffset: 0,
bevelSegments: 1
} );
const textMaterial = new THREE.MeshBasicMaterial({color: 0xffff00})
const textMesh = new THREE.Mesh(textGeometry, textMaterial)
scene.add(textMesh)
return textMesh
}
// 初始化物件
let text = addText('')
接著,每當用戶選擇城市時,就更新文字Mesh,如下:
citySelect.addEventListener( 'change', (event) => {
// 移除上一個城市的文字mesh
text.removeFromParent()
// 新增文字mesh
text = addText(seletedCity.name)
// 設定文字位置於圖釘上
text.position.set(ring.position.toArray())
})
你會看到文字的方向怪怪的,這是因為它面向的方向並不是鏡頭。
我們只要文字面向鏡頭即可。
function animate() {
...
text.lookAt(...camera.position.toArray())
}
修完之後,我們由圖可見文字擋到圖釘了。
這個時候只要應用我們在Day3: Three.js空間座標!讓世界繞著我旋轉!討論到的translate()
即可。我更新了文字的位置,使得文字不會遮住圖釘:
// 0.2是我們在建立TextGeometry時的文字寬度
textMesh.geometry.translate(text.length*-0.2,0.2,0)
https://codepen.io/umas-sunavan/pen/RwyQGZm
以上就是透過3D地球特效開發所做的成果。歷經三篇實戰跟一篇原理,我們已經吸收了非常非常多東西,而且多數的技術,都從過去文章介紹的原理疊加而來。
經過這四篇,我們學到的東西包含:
事實上,我們只做到冰山一角,地球能做的功能真的太多了,沒辦法在預計的篇幅中介紹完。地球能做到的包含:
Vector3.project
/ Vector3.unproject
)CylinderGeometry
)Vector3.Lerp
, Curve
)諸如此類,這些都能由你發揮。如果有興趣,都可以複製我Codepen的程式碼來玩!
接下來我將以3D圓餅圖為示例,從中介紹線段、曲線、轉成Mesh、製作3D,其過程也將相當有趣。